Skip to content

perf(studio): stabilize virtualized timeline drops - #2706

Merged
miguel-heygen merged 1 commit into
mainfrom
codex/studio-timeline-e-timeline-drops-v2
Aug 4, 2026
Merged

perf(studio): stabilize virtualized timeline drops#2706
miguel-heygen merged 1 commit into
mainfrom
codex/studio-timeline-e-timeline-drops-v2

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes external asset and composition drops stable while timeline rows are virtualized. The drag session owns the preview and destination instead of relying on whichever row DOM node is mounted at release time.

Changes

  • Introduce a session-scoped timeline drop lifecycle.
  • Resolve drop destinations from stable timeline geometry and logical track identity.
  • Autoscroll near viewport edges without losing the active destination.
  • Reject stale drop events and clear preview state on leave, cancel, drop, and session replacement.

Stack

Family E, 3 of 7. Base: #2705. Next: #2707.

Validation

  • Full Family E tip: 301 Studio test files passed; 3,271 tests passed; 18 todos.
  • Studio typecheck, build, oxlint, oxfmt, diff checks, and Fallow audit pass with no new issues.
  • Focused drop tests cover virtualized destinations, autoscroll, stale sessions, and cleanup.

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 44546e8 to 41c39e1 Compare July 27, 2026 20:51
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch 2 times, most recently from 1575a4f to 14df396 Compare July 28, 2026 20:54
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 41c39e1 to c940891 Compare July 28, 2026 20:54
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from 14df396 to 9db0c37 Compare July 28, 2026 22:10
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from c940891 to dc4a8d9 Compare July 28, 2026 22:11
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from 9db0c37 to a9560c9 Compare July 28, 2026 22:36
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from dc4a8d9 to 3a79ccd Compare July 28, 2026 22:36
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from a9560c9 to 2157ab1 Compare July 28, 2026 23:04
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 3a79ccd to 7121f49 Compare July 28, 2026 23:04
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from 2157ab1 to e7380ea Compare July 28, 2026 23:24
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch 2 times, most recently from 37f28cd to 202bf3a Compare July 28, 2026 23:54
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from e7380ea to 1eb940b Compare July 28, 2026 23:54
@miguel-heygen
miguel-heygen changed the base branch from codex/studio-timeline-e-keyframe-retiming-v2 to main July 29, 2026 02:07
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 202bf3a to 90ba298 Compare August 1, 2026 13:34
@miguel-heygen
miguel-heygen changed the base branch from main to codex/studio-timeline-e-keyframe-retiming-v2 August 1, 2026 13:35
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 90ba298 to f9ffc91 Compare August 1, 2026 13:43
@miguel-heygen
miguel-heygen marked this pull request as ready for review August 1, 2026 13:51

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE at f9ffc9148e.

Cleanest PR in the Family E stack. Session-epoch snapshotting at dragover-time gates commit at drop-time; autoscroll RAF chain self-terminates when session changes; drop-callback errors are swallowed so an external producer's rejection can't leave the actor stranded. Consistent with #2704 and #2705's design language. No blockers.

What I verified

Session-epoch drop-invalidation (timelineDragDrop.ts:167, 220)

activeDropEpochRef is captured at handleAssetDragOver time from the current sessionEpoch. At drop time, handleAssetDrop checks canCommit = activeDropEpochRef.current === sessionEpoch — if session changed between dragover and drop (composition switch, timeline reset), the drop is rejected. Directly addresses the "session change mid-drag" failure mode.

useEffect(() => { stopAutoScroll(); setIsDragOver(false); }, [sessionEpoch, stopAutoScroll]) at line 251 also fires visually on session change — the drop preview overlay clears, so users see the session boundary instead of a phantom drop indicator.

Autoscroll RAF chain self-terminates on session change (line 128-135)

stepAutoScroll re-checks pointer.sessionEpoch !== sessionEpoch on each frame. If session changes mid-chain, the RAF exits at the next frame — no lingering autoscroll animation. Combined with the sessionEpoch effect at line 251 that also nulls the RAF handle, autoscroll teardown covers both React-side (effect firing) and native-side (RAF terminating).

Placement resolution uses stable geometry, not DOM position (line 189-215)

resolveDropPlacement computes track + start from:

  • scrollRef.current.getBoundingClientRect() — stable scroll container (doesn't unmount when rows scroll)
  • ppsRef.current, durationRef.current, trackOrderRef.current, rowGeometryRef.current.rowHeights — all refs (always current, no closure staleness)
  • contentOrigin — timeline zero-offset from the scroll container's content edge

The row's DOM node is never touched. Even if the destination row is scrolled out and unmounted, resolveTimelineAssetDrop derives the drop track from rowHeights + trackOrder (the logical geometry) plus the pointer's clientY. Exactly the "resolve drop destinations from stable timeline geometry and logical track identity" claim from the PR body.

"Add at playhead" invariant preserved (line 56-59, 209-212, doc comment 91-98)

resolveDropStart(usePointerStart, pointerStart): if the caller opts into pointer-derived start (only composition drops at line 227), returns pointerStart; otherwise reads usePlayerStore.getState().currentTime synchronously at drop-time. No stale closure — the playhead value is captured at commit. Comment at line 91-98 documents this as a deliberate product choice (CapCut-style). Correct that composition drops opt in to pointer-x start (they're already-authored blocks with intended timing) while asset/file/block drops use the playhead.

Composition drop bypasses the file/asset/block fallback chain (line 223-230)

Compositions are checked first via parseTimelineCompositionPayload(...); if the payload parses AND onCompositionDrop is defined, we commit and early-return. Prevents a drag that carries both the composition MIME AND another MIME from double-dispatching. Correct precedence.

Callback error isolation (line 48-54)

function invokeDropCallback(callback: () => Promise<void> | void): void {
  try {
    void Promise.resolve(callback()).catch(() => undefined);
  } catch {
    // A rejected external producer never keeps a timeline drop actor alive.
  }
}

Handles sync throw, async reject, AND undefined | Promise<void> return type via Promise.resolve. Same shape used for onFileDrop / onAssetDrop / onBlockDrop / onCompositionDrop — all four paths equally isolated. A misbehaving external drop producer can't crash or hang the timeline.

DragLeave contains-check (line 180-187)

related instanceof Node && e.currentTarget.contains(related) correctly filters the "moving from parent to child within the drop zone" case where dragLeave fires but the drag is still logically inside the zone. Prevents flicker.

Cleanup on unmount (line 250)

useEffect(() => clearDropPreview, [clearDropPreview]) — the effect body returns clearDropPreview as its cleanup function. Under React's effect semantics, this calls clearDropPreview() on unmount and on clearDropPreview identity change (which is stable via useCallback deps). Effectively "clean up preview state when the hook unmounts." Slightly non-idiomatic (I'd usually write () => () => clearDropPreview()) but semantically identical.

Non-blocker observations

  1. activeDropEpochRef.current is set on dragover but not cleared on session change. The line 251 effect clears isDragOver and stops autoscroll but leaves activeDropEpochRef.current set to the OLD session's value. If the epoch monotonically increases (typical), the mismatch check at line 220 will correctly reject the drop. Only fails if the epoch ever cycles back to a prior value — highly unlikely in practice. Non-blocker; could add explicit activeDropEpochRef.current = null in the sessionEpoch effect for defense in depth, but not required.

  2. applyTypedJsonDrop returns true even when the payload is malformed (line 78-88). The MIME being present in transfer.types is enough to return true, which stops the fallback chain in handleAssetDrop. If a drag carries a malformed asset payload AND a valid block payload, the block wouldn't fire. Extremely rare (one drag = one MIME) but worth noting. Non-blocker.

  3. resolveTimelineAssetDrop returns { start, track } — I did not verify the geometry math in this diff. Assumed correct based on pre-existing usage from Family B/D timeline stacks. Nothing in this PR changes the internal math.

Family E stack ack

Family E, 3 of 7. Base is #2705 (approved above). Consistent design: session-epoch snapshotting + stale-check pattern (mirrors #2705's sessionEpoch field on actor state), autoscroll RAF chain with sessionEpoch guard (mirrors #2705's stepAutoScroll gate at line 254), stable-scroll-viewport geometry resolution (mirrors #2704's scrollRef capture). Cleanest and smallest PR of the three, which is fitting for the "external drop" scope (fewer moving parts than clip drag or keyframe retime).

CI: 9 passing, 1 running, 0 failing at time of review.

— Review by Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanest of the three from where I sit. The sessionEpoch freshness token (activeDropEpochRef set on dragover, checked at drop) mirrors the gesture-ownership pattern the base of the stack establishes, and the new handleAssetDragLeave that respects currentTarget.contains(relatedTarget) fixes the descendant-bubble flicker. Destination resolution is now geometry+ref-driven rather than DOM-node-tied — a scroll-out row cannot silently redirect the drop.

Nits

  • packages/studio/src/player/components/timelineDragDrop.ts:86applyTypedJsonDrop returns true on MIME presence alone (regardless of payload && apply), so an asset MIME with an undefined onAssetDrop callback no longer falls through to onBlockDrop the way the old code did. Practical impact is essentially zero (Timeline.tsx wires both handlers or neither, and having both mimes on one transfer is unusual), but it is a semantic drift buried under a perf(...) prefix — flagging so you can decide if the swallow is desired.
  • packages/studio/src/player/components/timelineDragDrop.ts:441 (Timeline.tsx) — no dedicated dragend/dragcancel handler. Author's Cleanup list mentions "leave, cancel, drop, session-replacement," but "cancel" has no browser-side hook — the target's dragleave only fires reliably when the drag exits the target. A drag terminated via Escape or ended outside the container (WebKit is inconsistent here) can leave the isDragOver ring stuck until the next dragover. Not a functional bug — just a stale visual.

What I didn't verify

  • The 3,271-test tip suite (accept on trust — CI is green).
  • Manual QA on WebKit for the Escape/outside-cancel visual persistence — call it out as a nit rather than a claim.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from 79752fd to 0c2b929 Compare August 4, 2026 02:21
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from f9ffc91 to 27cf79b Compare August 4, 2026 02:21
vanceingalls
vanceingalls previously approved these changes Aug 4, 2026

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE at 27cf79b17 — re-stamping the rebased head.

Delta from f9ffc9148e: rebased onto main + addressed my two P2 observations. Verified at the new head:

  • Malformed-payload fall-throughtimelineDragDrop.test.tsx:171: "falls through a malformed asset payload to a valid block payload." My earlier concern was that applyTypedJsonDrop returned true even when the JSON was malformed, stopping the fallback chain in handleAssetDrop. This test pins the new behavior — bad asset JSON no longer prevents a valid block payload from firing.
  • StrictMode coveragetimelineDragDrop.test.tsx:197: "clears an escaped drag after StrictMode effect replay." Test harness at :42 accepts a strict?: boolean option that wraps the child in <React.StrictMode> (:72-75). Directly addresses my P2 about "no StrictMode test for the drop hook."
  • Explicit session cleanup — Miga's PR body mentions "drag-end/session cleanup is explicit." Combined with the session-epoch effect at timelineDragDrop.ts (existing) that resets isDragOver + stops autoscroll, and the new StrictMode test, session lifecycle is now covered on the terminal paths.

The other minor concern I raised (activeDropEpochRef not cleared on session change) is still valid but non-blocking — the mismatch check at handleAssetDrop line 220 compares against the NEW sessionEpoch, so a stale value fails the check correctly. Defense-in-depth would null it, but functionality is fine.

CI: verified against the same passing surface (per Miga's message).

— Review by Via

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-keyframe-retiming-v2 branch from 0c2b929 to e12dba8 Compare August 4, 2026 02:50
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 27cf79b to 6bbc563 Compare August 4, 2026 02:50

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 6bbc563a (auto-restacked from 27cf79b17git diff --stat 27cf79b17 6bbc563a is empty; the SHA shift is purely Graphite re-parenting onto the fixed #2705 head, no code delta).

Clean. Miguel's three claims all substantiated:

  • Malformed typed payloads fall throughtimelineDragDrop.ts:71-91 applyTypedJsonDrop guards !apply || !mime and returns the boolean from applyJsonDropPayload (which returns false on JSON.parse throw or missing value); the call chain at :224-229 chains applyFileDrop → applyTypedJsonDrop(ASSET) → applyTypedJsonDrop(BLOCK) so a malformed asset payload with a valid block payload correctly reaches the block branch. Pinned by timelineDragDrop.test.tsx:151-172 falls through a malformed asset payload to a valid block payload asserting onAssetDrop not called + onBlockDrop called exactly once. Prior shape returned true unconditionally once MIME was present, silently eating malformed drops.
  • Drag-end / session cleanup explicit — two paired effects at timelineDragDrop.ts:239-246: (1) window.dragend clears dragPointerRef via clearDropPreview with a double-clear on cleanup; (2) [clearDropPreview, sessionEpoch]-scoped effect clears actor on epoch bump. clearDropPreview (:170-174) stops autoscroll RAF and nulls the ref. On the clip-drag side, timelineClipDragGestureLifecycle.ts:326-333 returns a cleanup that runs cancelGesture({updateReact:false}), resets cancelGestureRef.current, and removes all four window pointer listeners + the keydown-capture listener.
  • StrictMode-covered — two explicit tests: timelineDragDrop.test.tsx:194-205 clears an escaped drag after StrictMode effect replay and TimelineClipDiamonds.test.tsx:265-278 publishes retime previews after StrictMode effect replay. Mechanism holds because mountTimelineClipDragGestureLifecycle returns a cleanup that fully tears down (listeners removed + cancelGestureRef.current reset to () => false), and subscribeTimelineKeyframeRetimePreview returns an unsubscribe closure so the double-mount adds → removes → re-adds without leaks.

No new concerns from the delta scan.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen changed the base branch from codex/studio-timeline-e-keyframe-retiming-v2 to main August 4, 2026 02:57
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review August 4, 2026 02:57

The base branch was changed.

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-e-timeline-drops-v2 branch from 6bbc563 to e618cf2 Compare August 4, 2026 03:15

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at e618cf2a (rebased onto main post-#2705-merge).

Byte-identical to my prior R2 head at 6bbc563a:

  • git diff --stat 6bbc563a e618cf2a — empty.
  • Cumulative patch-ID git diff origin/main..e618cf2a | git patch-id --stable matches git diff origin/main..6bbc563a | git patch-id --stable at 64898e4b5a0f457912f23faf4160d63968cf7c59.

No content delta — pure Graphite re-parent onto the post-#2705 main. Prior R2 conclusions stand: malformed typed payload fall-through, drag-end / session cleanup, and StrictMode replay all substantiated with test coverage. Zero open concerns.

Ready from where I sit.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE at e618cf2afc — byte-clean re-stamp of my prior approve at 27cf79b17.

Confirmed via GitHub contents API — all three primary files have identical blob SHAs at old and new heads:

  • timelineDragDrop.ts: 389c8a84a1dc3f8a495dfc7a880c490e6c5d045d
  • timelineDragDrop.test.tsx: cde1a319c565399548d2b7cbe4c2614704a2bba4
  • timelineTestViewport.ts: a7815d31a4d7f595b3a7eb2ad7326652897fddd1

Rebase moves the delta over current main (post-#2704 and post-#2705 merges); PR content unchanged. All prior findings (malformed-payload fall-through + StrictMode effect-replay + session-epoch snapshotting) hold at this head.

CI in progress at time of review.

— Review by Via

@miguel-heygen
miguel-heygen merged commit bb5c603 into main Aug 4, 2026
68 of 69 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-e-timeline-drops-v2 branch August 4, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants